iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0

對一個 Agent 來說,工具調用(tool use)是極其重要的功能之一,而這個「工具」具體來說,其實就是一段「Python 的函數」,模型會指定要調用的工具,以及對應的參數,具體的邏輯如下:

  1. 模型給出工具調用請求,包含工具名稱和參數
  2. 程序解析模型輸出的調用請求,找出要使用的函數並傳入參數做呼叫。
  3. 工具函數回傳值並餵回模型。
  4. 模型給出對工具結果的回答(可能是給出最終回覆,或繼續調用工具)。

而這幾篇(會分成上中下三篇),我們關注的只有工具函數,暫時不管要怎麼回到 agent.py 中給模型調用,也就是只要關注:

  1. 傳入的工具名稱 -> 做解析定出目標函數。
  2. 傳入的參數 -> 給到目標函數,通常是字串或數字。
  3. 目標函數返回字串,傳回調用處。

註冊工具和工具函數的屬性

什麼是 MCP?

也就是「上下文協議」,由 Anthropic 提出的統一標準,可以理解為 AI 應用的「通用接口」,可以統一提示詞範本、做資源讀取以及工具調用。


註冊 MCP 形式的工具

我們先建立一個 MCP Server,
而在 Agent 調用工具時,通常會先問你「是否可以調用 ... 工具」,
在這裡,我把工具分成兩類:

  1. 不需審核就可直接調用的,如讀取文件。
  2. 需要審核:如撰寫文件等會做改動的工具。
    加上 need_approval 這個屬性,並註冊到 TOOL_REGISTRY,最後利用 .add_tool() 註冊到 MCP Server,達到「雙重註冊」。
# src/meowgent/tool.py
from typing import Annotated, Callable, Dict, Any
from pathlib import Path
from mcp.server.mcpserver import MCPServer
import logging

mcp = MCPServer("Meowgent")
logging.getLogger().handlers.clear() # 刪去 mcp 做的日誌綁定

TOOL_REGISTRY: Dict[str, Callable] = {}

def tool_register(need_approval: bool = True):
    """函數裝飾器:同時註冊到 MCP 伺服器與內部字典"""
    
    def decorator(func: Callable):
        func.need_approval = need_approval # 標記是否需要審核
        
        TOOL_REGISTRY[func.__name__] = func # 註冊給 agent.py 內部使用
        mcp.add_tool(func) # 註冊給 MCP 協議外部調用
        
        return func
    return decorator

一般來說,常會看到的 MCP 註冊方式會是在工具函數上面掛上裝飾器 - @mcp.tool(),而因為我們需要做 need_approval 的屬性,還有後期在 agent.py 做的 .submit() 工具並發運行機制,所以雖然比較麻煩,我們不把工具執行交給 MCP server,而是等下自行設計工具執行邏輯

關於刪去日誌綁定的部分:
MCPServer 建立 MCP 物件後,原始碼中會執行 handlers.append(RichHandler(...))logging.basicConfig(level="INFO", ..., handlers=handlers) 可以理解為綁定了「強迫讓常態的運行狀態消息被顯示」的功能,而我們利用 logging.getLogger().handlers.clear() 來解除這樣的日誌綁定。

關於函數裝飾器 - tool_register() 的部分:
它的作用是在「不修改」原函數的情況下,為函數新增功能。
而在這裡,要做的三件事,加上標籤以及註冊身份:

  1. 用傳入的參數標記是否需要審核。
  2. 註冊到字典。
  3. 註冊到 MCP Server。

我們來一步一步看一下是如何進行的:

  1. 先執行了 tool_register(need_approval=False),把參數記住,並回傳內層的 decorator()
	@tool_register(need_approval=False)
	def my_tool():
		 ...
  1. decorator() 進到了內層函數,執行上面所說的三件事。
  2. 內層函數回傳原 func,可以理解為把加上的標籤以及註冊的身份送到了 my_tool() 手上,就大功告成啦!

工具的指揮官

拿到工具名稱,我們進到註冊表裡查詢是否有此工具函數存在,有則傳入參數到函數內執行,無則回報。

# src/meowgent/tool.py
...

def execute_tool(tool_name: str, args: dict) -> str:
    """供 agent.py 調用執行的統一入口"""
    if tool_name not in TOOL_REGISTRY:
        return f"錯誤:找不到工具 '{tool_name}'"
    try:
        tool_func = TOOL_REGISTRY[tool_name]
        return str(tool_func(**args))
    except Exception as e:
        return f"錯誤:執行工具 '{tool_name}' 失敗:{e}"

在這裡,不用去關注到「審核是否通過的問題」,這是在 agent.py 關注的,如果未通過,就不會去呼叫 execute_tool()

接著,我們先來實作幾個簡單的工具吧!


工具函數的要求

有以下幾點要求:

  1. 標註審核屬性 -> @tool_register(bool)
  2. 清晰的工具註解 -> """ ... """ 讓模型可以理解作用。
  3. 清晰的參數註解 -> Annotated[型別, "參數解釋"] 讓模型知道這是什麼參數。
  4. 完善的例外處理 -> 用 try...except 包起,出錯明確回傳原因。

要記得,所有傳入的型別只有「字串以及數字」,回傳則只有「字串」。


讀取文字檔

這裡把 file_path 轉為 Path 物件,方便做路徑的處理以及讀取,
先用 .expanduser() 展開家目錄波浪號( ~/ 展開為絕對路徑),接著就可以直接用 .read_text() 讀取了,最後若報錯明確回報讀取失敗。

為什麼加上 encoding="utf-8"
這是為了防止「解碼錯誤」,簡單來說就是電腦拿著「錯誤的密碼本(編碼格式)」,試圖把檔案裡的二進位位元組(Bytes)翻譯回人類看得懂的文字,結果對不上,導致程式直接崩潰或文字變成亂碼
而 UTF-8 既相容了傳統的 ASCII 形式,又能以最優方式收錄全世界的語言與 Emoji,因此成為被廣泛使用的統一編碼。

# src/meowgent/tool.py
...

@tool_register(False)
def read_file(
    file_path: Annotated[str, "要讀取的檔案路徑(支援相對路徑或以 ~ 開頭的路徑)"]
) -> str:
    """ 讀取文字檔 """
    try:
        return Path(file_path).expanduser().read_text(encoding="utf-8")
    except Exception as e:
        return f"錯誤:讀取檔案 '{file_path}' 失敗:{e}"

編寫文字檔

路徑一樣轉 Path 並改為絕對路徑
Path(file_path).expanduser().parent.mkdir(parents=True, exist_ok=True) 是為了防止因為「資料夾不存在」而導致寫入失敗parents=True 讓不管幾層資料夾不存在都能被建立好;exist_ok=True 讓資料夾存在的狀況下不衝突。
再來透過 .write_text() 寫入檔案,同樣指定 encoding="utf-8" 確保編碼儲存正常。
最後也加上報錯的回傳。

# src/meowgent/tool.py
...

@tool_register(True)
def write_file(
    file_path: Annotated[str, "要寫入的目標檔案路徑"],
    content: Annotated[str, "要寫入檔案的完整文字內容"]
) -> str:
    """ 寫入到文字檔 """
    try:
        Path(file_path).expanduser().parent.mkdir(parents=True, exist_ok=True) # 建立上層資料夾

        Path(file_path).expanduser().write_text(content, encoding="utf-8")

        return f"成功寫入檔案 '{file_path}'(共 {len(content.splitlines())} 行)"
        # content.splitlines() 字串分行拆成串列
    
    except Exception as e:
        return f"錯誤:寫入檔案 '{file_path}' 失敗:{e}"

今天這篇,我們瞭解了什麼是工具調用,寫了註冊函數、調用函數的邏輯,以及讀寫文字檔的工具。
下一篇,我們繼續來做更多的工具!


上一篇
Day 1 - 打造屬於自己的 Agent!
下一篇
Day 3 - 加入工具吧 - 中
系列文
手刻 AI Agent!大一新生的 Python 實戰筆記7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言